#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import re
import sys
import stat
import time
import errno
import fcntl
import struct
import socket
import shutil
import random
import pathlib
import asyncio
import termios
import hashlib
import blessed
import zipfile
import tempfile
import subprocess
import strict_hwcfg
from datetime import datetime
from OpenSSL import crypto
from gi.repository import Gio


class Util:

    @staticmethod
    def die(message):
        print(message, file=sys.stderr)
        sys.exit(1)

    @staticmethod
    def forceDelete(path):
        if os.path.islink(path):
            os.remove(path)
        elif os.path.isfile(path):
            os.remove(path)
        elif os.path.isdir(path):
            shutil.rmtree(path)
        elif os.path.lexists(path):
            os.remove(path)             # other type of file, such as device node
        else:
            pass                        # path does not exist, do nothing

    @staticmethod
    def strListMaxLen(strList):
        maxLen = 0
        for lname in strList:
            if len(lname) > maxLen:
                maxLen = len(lname)
        return maxLen

    @staticmethod
    def getDirLastUpdateTime(dirpath):
        out = Util.shellCall("find \"%s\" -printf \"%%TY%%Tm%%Td%%TH%%TM%%TS\\n\" | /bin/sort | /bin/tail -1" % (dirpath))
        out = re.search(r'^(.*)\.', out).group(1)
        return datetime.strptime(out, "%Y%m%d%H%M%S")

    @staticmethod
    def listDirWithoutKeepFiles(dirpath):
        ret = []
        for fn in os.listdir(dirpath):
            if fn.startswith(".keep"):
                continue
            ret.append((fn, os.path.join(dirpath, fn)))
        return ret

    @staticmethod
    def githubGetFileContent(user, repo, filepath):
        with TempCreateFile() as tmpFile:
            url = "https://github.com/%s/%s/trunk/%s" % (user, repo, filepath)
            Util.cmdCall("svn", "export", "-q", "--force", url, tmpFile)
            return pathlib.Path(tmpFile).read_text()

    @staticmethod
    def formatSize(value):
        # value is in bytes
        if value > 1024 * 1024 * 1024 * 1024:
            return "%.1fTiB" % (value / 1024 / 1024 / 1024 / 1024)
        elif value > 1024 * 1024 * 1024:
            return "%.1fGiB" % (value / 1024 / 1024 / 1024)
        elif value > 1024 * 1024:
            return "%.1fMiB" % (value / 1024 / 1024)
        elif value > 1024:
            return "%.1fKiB" % (value / 1024)
        else:
            assert False

    @staticmethod
    def systemdIsUserServiceEnabled(serviceName):
        obj = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SYSTEM,
                                             Gio.DBusProxyFlags.NONE,
                                             None,
                                             "org.freedesktop.systemd1",            # bus_name
                                             "/org/freedesktop/systemd1",           # object_path
                                             "org.freedesktop.systemd1.Manager")    # interface_name
        return (obj.GetUnitFileState("(s)", serviceName) == "enabled")

    @staticmethod
    def systemdGetAllUserServicesEnabled():
        obj = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SYSTEM,
                                             Gio.DBusProxyFlags.NONE,
                                             None,
                                             "org.freedesktop.systemd1",            # bus_name
                                             "/org/freedesktop/systemd1",           # object_path
                                             "org.freedesktop.systemd1.Manager")    # interface_name
        ret = []
        for unitFile, unitState in obj.ListUnitFiles():
            if unitState == "enabled":
                ret.append(os.path.basename(unitFile))
        return ret

    @staticmethod
    def systemdIsUserUnitRunning(unitName):
        obj = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SYSTEM,
                                             Gio.DBusProxyFlags.NONE,
                                             None,
                                             "org.freedesktop.systemd1",            # bus_name
                                             "/org/freedesktop/systemd1",           # object_path
                                             "org.freedesktop.systemd1.Manager")    # interface_name
        unit = obj.GetUnit("(s)", unitName)
        return (unit.ActiveState == "active")

    @staticmethod
    def is_int(s):
        try:
            int(s)
            return True
        except ValueError:
            return False

    @staticmethod
    def testZipFile(filename):
        with zipfile.ZipFile(filename, 'r', zipfile.ZIP_DEFLATED) as z:
            return (z.testzip() is None)

    @staticmethod
    def md5hash(s):
        return hashlib.md5(s.encode('utf-8')).hexdigest()

    @staticmethod
    def removeDuplication(theList):
        ret = []
        theSet = set()
        for k in theList:
            if k not in theSet:
                ret.append(k)
                theSet.add(k)
        return ret

    @staticmethod
    def pad(string, length):
        '''Pad a string with spaces.'''
        if len(string) <= length:
            return string + ' ' * (length - len(string))
        else:
            return string[:length - 3] + '...'

    @staticmethod
    def terminal_width():
        '''Determine width of terminal window.'''
        try:
            width = int(os.environ['COLUMNS'])
            if width > 0:
                return width
        except:
            pass
        try:
            query = struct.pack('HHHH', 0, 0, 0, 0)
            response = fcntl.ioctl(1, termios.TIOCGWINSZ, query)
            width = struct.unpack('HHHH', response)[1]
            if width > 0:
                return width
        except:
            pass
        return 80

    @staticmethod
    def realPathSplit(path):
        """os.path.split() only split a path into 2 component, I believe there are reasons, but it is really inconvenient.
           So I write this function to split a unix path into basic components.
           Reference: http://stackoverflow.com/questions/3167154/how-to-split-a-dos-path-into-its-components-in-python"""

        folders = []
        while True:
            path, folder = os.path.split(path)
            if folder != "":
                folders.append(folder)
            else:
                if path != "":
                    folders.append(path)
                break
        if path.startswith("/"):
            folders.append("")
        folders.reverse()
        return folders

    @staticmethod
    def isTwoDirSame(dir1, dir2):
        # FIXME: we could use python to do this
        return Util.cmdCallWithRetCode("diff", "-r", dir1, dir2)[0] == 0

    @staticmethod
    def touchFile(filename):
        assert not os.path.exists(filename)
        f = open(filename, 'w')
        f.close()

    @staticmethod
    def removeDirContentExclude(dirPath, excludeList):
        for fn in os.listdir(dirPath):
            if fn not in excludeList:
                Util.forceDelete(os.path.join(dirPath, fn))

    @staticmethod
    def removeEmptyDir(dirname):
        if len(os.listdir(dirname)) == 0:
            os.rmdir(dirname)

    @staticmethod
    def isCfgFileReallyNotEmpty(filename):
        with open(filename, "r") as f:
            for line in f.read().split("\n"):
                if line.strip() == "":
                    continue
                if line.startswith("#"):
                    continue
                return True
        return False

    @staticmethod
    def ensureAncesterDir(filename):
        assert os.path.isabs(filename)

        splist = []
        while True:
            filename, bf = os.path.split(filename)
            if bf == "":
                break
            splist.insert(0, bf)

        curd = "/"
        for d in splist[:-1]:
            curd = os.path.join(curd, d)
            if not os.path.isdir(curd):
                os.mkdir(curd)

    @staticmethod
    def getDirFreeSpace(dirname):
        """Returns free space in MB"""

        ret = Util.cmdCall("df", "-m", dirname)
        m = re.search("^.* + [0-9]+ +[0-9]+ +([0-9]+) + [0-9]+% .*$", ret, re.M)
        return int(m.group(1))

    @staticmethod
    def isDirAncestor(path1, path2):
        """check if path2 is the ancestor of path1"""
        return path1.startswith(path2 + "/")

    @staticmethod
    def getHomeDir(userName):
        if userName == "root":
            return "/root"
        else:
            return os.path.join("/home", userName)

    @staticmethod
    def cmdCall(cmd, *kargs):
        # call command to execute backstage job
        #
        # scenario 1, process group receives SIGTERM, SIGINT and SIGHUP:
        #   * callee must auto-terminate, and cause no side-effect
        #   * caller must be terminated by signal, not by detecting child-process failure
        # scenario 2, caller receives SIGTERM, SIGINT, SIGHUP:
        #   * caller is terminated by signal, and NOT notify callee
        #   * callee must auto-terminate, and cause no side-effect, after caller is terminated
        # scenario 3, callee receives SIGTERM, SIGINT, SIGHUP:
        #   * caller detects child-process failure and do appopriate treatment

        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             universal_newlines=True)
        if ret.returncode > 128:
            # for scenario 1, caller's signal handler has the oppotunity to get executed during sleep
            time.sleep(1.0)
        if ret.returncode != 0:
            print(ret.stdout)
            ret.check_returncode()
        return ret.stdout.rstrip()

    @staticmethod
    def cmdCallWithRetCode(cmd, *kargs):
        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        return (ret.returncode, ret.stdout.rstrip())

    @staticmethod
    def cmdCallWithInput(cmd, inStr, *kargs):
        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             input=inStr, universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        if ret.returncode != 0:
            print(ret.stdout)
            ret.check_returncode()
        return ret.stdout.rstrip()

    @staticmethod
    def cmdCallIgnoreResult(cmd, *kargs):
        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)

    @staticmethod
    def cmdCallTestSuccess(cmd, *kargs):
        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        return (ret.returncode == 0)

    @staticmethod
    def shellCall(cmd):
        # call command with shell to execute backstage job
        # scenarios are the same as Util.cmdCall

        ret = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             shell=True, universal_newlines=True)
        if ret.returncode > 128:
            # for scenario 1, caller's signal handler has the oppotunity to get executed during sleep
            time.sleep(1.0)
        if ret.returncode != 0:
            print(ret.stdout)
            ret.check_returncode()
        return ret.stdout.rstrip()

    @staticmethod
    def shellCallWithRetCode(cmd):
        ret = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             shell=True, universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        return (ret.returncode, ret.stdout.rstrip())

    @staticmethod
    def shellCallIgnoreResult(cmd):
        ret = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             shell=True, universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)

    @staticmethod
    def cmdExec(cmd, *kargs):
        # call command to execute frontend job
        #
        # scenario 1, process group receives SIGTERM, SIGINT and SIGHUP:
        #   * callee must auto-terminate, and cause no side-effect
        #   * caller must be terminate AFTER child-process, and do neccessary finalization
        #   * termination information should be printed by callee, not caller
        # scenario 2, caller receives SIGTERM, SIGINT, SIGHUP:
        #   * caller should terminate callee, wait callee to stop, do neccessary finalization, print termination information, and be terminated by signal
        #   * callee does not need to treat this scenario specially
        # scenario 3, callee receives SIGTERM, SIGINT, SIGHUP:
        #   * caller detects child-process failure and do appopriate treatment
        #   * callee should print termination information

        # FIXME, the above condition is not met, Util.shellExec has the same problem

        ret = subprocess.run([cmd] + list(kargs), universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        ret.check_returncode()

    @staticmethod
    def shellExec(cmd):
        ret = subprocess.run(cmd, shell=True, universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        ret.check_returncode()

    @staticmethod
    async def asyncStartCmdExec(cmd, *kargs, loop=None):
        assert loop is not None
        proc = await asyncio.create_subprocess_exec(cmd, *kargs, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, loop=loop)
        return (proc, proc.stdout)

    @staticmethod
    async def asyncWaitCmdExec(proc):
        retcode = await proc.wait()
        if retcode != 0:
            raise subprocess.CalledProcessError(retcode, [])      # use subprocess.CalledProcessError since there's no equivalent in asyncio

    @staticmethod
    def newBuffer(ch, li):
        ret = bytearray()
        i = 0
        while i < li:
            ret.append(ch)
            i += 1
        return bytes(ret)

    @staticmethod
    def genSelfSignedCertAndKey(cn, keysize):
        k = crypto.PKey()
        k.generate_key(crypto.TYPE_RSA, keysize)

        cert = crypto.X509()
        cert.get_subject().CN = cn
        cert.set_serial_number(random.randint(0, 65535))
        cert.gmtime_adj_notBefore(100 * 365 * 24 * 60 * 60 * -1)
        cert.gmtime_adj_notAfter(100 * 365 * 24 * 60 * 60)
        cert.set_issuer(cert.get_subject())
        cert.set_pubkey(k)
        cert.sign(k, 'sha1')

        return (cert, k)

    @staticmethod
    def dumpCertAndKey(cert, key, certFile, keyFile):
        with open(certFile, "wb") as f:
            buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
            f.write(buf)
            os.fchmod(f.fileno(), 0o644)

        with open(keyFile, "wb") as f:
            buf = crypto.dump_privatekey(crypto.FILETYPE_PEM, key)
            f.write(buf)
            os.fchmod(f.fileno(), 0o600)

    @staticmethod
    def isTrivalFileOrDir(filename):
        if os.path.islink(filename):
            return False
        if stat.S_ISCHR(os.stat(filename).st_mode):
            return False
        if stat.S_ISBLK(os.stat(filename).st_mode):
            return False
        if stat.S_ISFIFO(os.stat(filename).st_mode):
            return False
        if stat.S_ISSOCK(os.stat(filename).st_mode):
            return False
        return True

    @staticmethod
    def getAbsPathList(dirname, pathList):
        pathList2 = []
        for i in range(0, len(pathList)):
            assert not os.path.isabs(pathList[i])
            pathList2.append(os.path.join(dirname, pathList[i]))
        return pathList2

    @staticmethod
    def getFileList(dirName, level, typeList):
        """typeList is a string, value range is "d,f,l,a"
           returns basename"""

        ret = []
        for fbasename in os.listdir(dirName):
            fname = os.path.join(dirName, fbasename)

            if os.path.isdir(fname) and level - 1 > 0:
                for i in Util.getFileList(fname, level - 1, typeList):
                    ret.append(os.path.join(fbasename, i))
                continue

            appended = False
            if not appended and ("a" in typeList or "d" in typeList) and os.path.isdir(fname):         # directory
                ret.append(fbasename)
            if not appended and ("a" in typeList or "f" in typeList) and os.path.isfile(fname):        # file
                ret.append(fbasename)
            if not appended and ("a" in typeList or "l" in typeList) and os.path.islink(fname):        # soft-link
                ret.append(fbasename)

        return ret

    @staticmethod
    def readListFile(filename):
        ret = []
        with open(filename, "r") as f:
            for line in f.read().split("\n"):
                line = line.strip()
                if line != "" and not line.startswith("#"):
                    ret.append(line)
        return ret

    @staticmethod
    def hwcfgGetScreenDevices(machine):
        ret = []
        for d in machine.get_devices():
            if d.get_type() == strict_hwcfg.DeviceType.SCREEN:
                ret.append(d)
        for c in machine.get_components():
            if c.get_type() == strict_hwcfg.ComponentType.MONITOR:
                for d in c.get_devices():
                    if d.get_type() == strict_hwcfg.DeviceType.SCREEN:
                        ret.append(d)
        return ret

    @staticmethod
    def encodePath(src_path):
        # Use the convert algorithm of systemd:
        # * Some unit names reflect paths existing in the file system namespace.
        # * Example: a device unit dev-sda.device refers to a device with the device node /dev/sda in the file system namespace.
        # * If this applies, a special way to escape the path name is used, so that the result is usable as part of a filename.
        # * Basically, given a path, "/" is replaced by "-", and all unprintable characters and the "-" are replaced by C-style
        #   "\x20" escapes. The root directory "/" is encoded as single dash, while otherwise the initial and ending "/" is
        #   removed from all paths during transformation. This escaping is reversible.
        # Note:
        # * src_path must be a normalized path, we don't accept path like "///foo///bar/"
        # * the encoding of src_path is a bit messy
        # * what about path like "/foo\/bar/foobar2"?

        assert os.path.isabs(src_path)

        if src_path == "/":
            return "-"

        newPath = ""
        for c in src_path.strip("/"):
            if c == "/":
                newPath += "-"
            elif re.fullmatch("[a-zA-Z0-9:_\\.]", c) is not None:
                newPath += c
            else:
                newPath += "\\x%02x" % (ord(c))
        return newPath

    @staticmethod
    def decodePath(dst_path):
        if dst_path == "-":
            return "/"

        newPath = ""
        for i in range(0, len(dst_path)):
            if dst_path[i] == "-":
                newPath += "/"
            elif dst_path[i] == "\\":
                m = re.search("^\\\\x([0-9])+", dst_path[i:])
                if m is None:
                    raise ValueError("encoded path is invalid")
                newPath += chr(int(m.group(1)))
            else:
                newPath += dst_path[i]
        return "/" + newPath

    @staticmethod
    def verifyFileMd5(filename, md5sum):
        with open(filename, "rb") as f:
            thash = hashlib.md5()
            while True:
                block = f.read(65536)
                if len(block) == 0:
                    break
                thash.update(block)
            return thash.hexdigest() == md5sum

    @staticmethod
    def isBufferAllZero(buf):
        for b in buf:
            if b != 0:
                return False
        return True

    @staticmethod
    def getUnixDomainSocketPeerInfo(sock):
        # returns (pid, uid, gid)
        pattern = "=iii"
        length = struct.calcsize(pattern)
        ret = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, length)
        return struct.unpack(pattern, ret)

    @staticmethod
    def receiveFromUnixDomainSocket(path):
        serverSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            serverSock.bind(path)
            serverSock.listen(5)

            recvBuf = b''
            while True:
                new_sock, addr = serverSock.accept()
                print("debug ", addr)
                try:
                    pid, uid, gid = Util.getUnixDomainSocketPeerInfo(new_sock)
                    if uid != os.getuid():
                        continue

                    while True:
                        r = new_sock.recv(4096)
                        if r == b'':
                            break
                        recvBuf += r
                    break
                finally:
                    new_sock.close()

            return recvBuf
        finally:
            serverSock.close()
            Util.forceDelete(path)


class SingletonProcess:

    class AlreadyExistException(Exception):
        pass

    def __init__(self, filename):
        self._lockfile = filename
        self._lockFd = os.open(self._lockfile, os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC, 0o600)
        try:
            fcntl.lockf(self._lockFd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            return
        except Exception as e:
            os.close(self._lockFd)
            self._lockFd = None
            if isinstance(e, IOError):
                if e.errno == errno.EACCES or e.errno == errno.EAGAIN:
                    raise self.AlreadyExistException()
            raise

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.close()

    def close(self):
        os.close(self._lockFd)
        self._lockFd = None
        os.unlink(self._lockfile)
        self._lockfile = None


class TempChdir:

    def __init__(self, dirname):
        self.olddir = os.getcwd()
        os.chdir(dirname)

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        os.chdir(self.olddir)


class TempCreateFile:

    def __init__(self, dir=None):
        f, self._fn = tempfile.mkstemp(dir=dir)
        os.close(f)

    def __enter__(self):
        return self._fn

    def __exit__(self, type, value, traceback):
        os.unlink(self._fn)


class InfoPrinter:

    class _InfoPrinterInfoIndenter:

        def __init__(self, parent, message, bRecallable=False):
            self._parent = parent
            self._bRecallable = bRecallable

            self._savedIndenter = self._parent._curIndenter
            self._parent._curIndenter = self

            self._printLen = -1
            self._parent.printInfo(message)
            self._printLen = len(message)

            self._parent.incIndent()

        def __enter__(self):
            return self

        def __exit__(self, type, value, traceback):
            self._parent.decIndent()

            if self._bRecallable and self._printLen >= 0:
                sys.stdout.write("\r" + self._parent._t.clear_eol)       # clear current line
                sys.stdout.flush()

            self._parent._curIndenter = self._savedIndenter

    def __init__(self):
        self._t = blessed.Terminal()
        self._indent = 0
        self._curIndenter = None

    def incIndent(self):
        self._indent = self._indent + 1

    def decIndent(self):
        assert self._indent > 0
        self._indent = self._indent - 1

    def printInfo(self, s):
        line = ""
        line += self._t.green("*") + " "
        line += "\t" * self._indent
        line += s

        if self._curIndenter is not None and self._curIndenter._bRecallable:
            if self._curIndenter._printLen == -1:
                print(line, end='')
            else:
                self._curIndenter._bRecallable = False
                print("")
                print(line)
        else:
            print(line)

    def printError(self, s):
        line = ""
        line += self._t.red("*") + " "
        line += "\t" * self._indent
        line += s

        if self._curIndenter is not None and self._curIndenter._bRecallable:
            if self._curIndenter._printLen == -1:
                print(line, end='')
            else:
                self._curIndenter._bRecallable = False
                print("")
                print(line)
        else:
            print(line)

    def printInfoAndIndent(self, s, bRecallable=False):
        return self._InfoPrinterInfoIndenter(self, s, bRecallable)


class DynObject:
    pass
